feat(plugin-auth): a membership that ends takes the session's claim on that organization with it — re-pointed if the user belongs elsewhere, revoked if not - #15879
Conversation
…efore review) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
#15784) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…regenerate i18n bundles (#15784) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… test double judgeable, re-derive the two censuses (#15784) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 2 package(s): 60 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 6 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 12 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin a8a0cc2927c780bc95c84962ed45c01ecbf67903 && git checkout a8a0cc2927c780bc95c84962ed45c01ecbf67903
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin af29b662b06cc41d5a4f4c1d2cfaf27cb2ef123d 61bc592f3d091ce71b6e01f30d0fd46cbe959450 && git checkout -B drift-repro af29b662b06cc41d5a4f4c1d2cfaf27cb2ef123d && git merge --no-ff 61bc592f3d091ce71b6e01f30d0fd46cbe959450
node scripts/docs-audit/affected-docs.mjs --json af29b662b06cc41d5a4f4c1d2cfaf27cb2ef123d
|
… the widened revoke_reason description (#15784) The three translated locales' *.source-hashes.generated.ts files were left stale by the field-description change: the hashes are keyed on the source strings, so check:i18n read all three bundle sets as DRIFTED. Regenerated with the whole pass (pnpm i18n:extract), which emits both families together. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Fixes #15784
Removing a member deleted the
sys_memberrow and left the session alive, for up to sevendays. The security half was already shut per request by the ruling family's boundary card
(#15409): a session whose
activeOrganizationIdis not backed by a membership resolveswith no active organization. This is the courtesy half — when an admin clicks
"Remove member", that person is actually signed out.
⛔ What this is NOT
It is not the enforcement, and nothing here may be read as making the per-request check
optional.
packages/core/src/security/resolve-authz-context.tsis untouched — verifiablein one line:
git diff 59953d5a3 -- packages/core/src/security/resolve-authz-context.tsisempty. Scope item 3 stands verbatim, and the new module's header says why in the place a
future reader will be standing: a trigger can be missed, an evaluation cannot.
The census below measured a path this trigger does not cover, and it is named in the
module header rather than left to be discovered.
The census came first (scope item 1)
Posted on the card before anything was wired:
#15784 (comment)
Three readings, each with its own control, over
objectstack@59953d5a3andcloud@3856fbf7c. Two findings shaped the implementation:deletes through a variable object name (
dataEngine.delete(objectName, …)), so adetector scanning for
sys_memberfinds zero delete call sites in production —a true answer to a misleading question. The second reading enumerates the generic doors
instead and classifies each by the abstraction it holds.
caller: cloud's package-uninstall sample-data purge takes its object name from the
installed package's manifest, ungated. Filed as cloud#2003; not fixed here.
The controls did work rather than decorate: the generic-door detector failed its own
control by over-firing on
membershipCache.delete(cacheKey)and was tightened before itsnumbers were used, and the runtime probe's cascade path first reported
row gone=false; NOTHINGbecause a required lookup restricted the user delete — the cascade had never run,so it had never been measured.
Ruled shape: act on the organization's claim, never on the user
Maintainer ruling, decision batch #49 item 4, option B
(#15784 (comment)):
⛔ Option A (revoke every session of the user) and ⛔ option C (do nothing) are both
rejected on the card. The second row is the director seat's application where the option
text is silent, flagged there as flippable in one line; the measurement turned up no reason
it should be otherwise, so it is implemented as ruled.
Why the seam is an engine hook, not the endpoint
packages/plugins/plugin-auth/src/membership-ended-session.tsregistersafterDeleteandafterUpdateonsys_member. The census measured which writers reach that seam:A hook on
/organization/remove-memberwould have covered exactly one row of that table.This is also the precedent already in the package:
last-admin-guard.tsenforces itsinvariant with
beforeUpdate/beforeDeleteonsys_memberfor the same stated reason.Both new hooks are after hooks on purpose — a removal
last-admin-guardrefuses atbeforeDelete(priority 20) must not have its sessions touched.The revocation reuses the existing mechanism unchanged:
expires_ata second into the pastplus
revoked_at/revoke_reason, the same shapeenforceSessionControlsandenforceConcurrentCapalready write.session-tombstone.tsalready hides a revoked rowfrom better-auth's session reads, so
findSessionanswersnulland the Console's existing401 to login redirect handles it. No client change.
Clause-② — the accept-set widening, re-judgeable from the diff
Yes, this widens an accept set, and the reviewer needs two facts to re-judge it:
packages/spec— measured:grep -rn 'revoke_reason\|RevokeReason' packages/spec/src/returns nothing. The column isField.text({ maxLength: 64 })with no Zod enum behind it, so the field description onthe published system object IS the vocabulary, and that is the surface this PR widens
(
packages/platform-objects/src/identity/sys-session.object.ts, plus the four generatedi18n bundles that mirror it).
organization_membership_ended— thefirst authorization-event reason on a column whose every prior value is a timer
(
idle_timeout,absolute_max,concurrent_cap) or an interactive revoke(
user_revoked,admin). It is deliberately the same string the API-key arm of thissame ruling family already mints for this event (
authRefusal.reasoninresolve-authz-context.ts, from [decision · p0] an ex-member API key reads AND writes another organization's rows on the single-kernel wiring underisolated— the wall compares against the caller's own unvetted claim #15256 decision 1A), so one grep finds every place theplatform acts on a membership ending, across both credential kinds. 29 characters, inside
the column's 64.
Changeset — derived from the diff, and it diverges from the ruling's literal split
The ruling said
@objectstack/specminor plus the implementing package patch,"derive the exact set from your diff". The derivation lands elsewhere, and the divergence is
declared rather than quietly taken:
@objectstack/spec: nothing. The diff does not touch it, because the reasonvocabulary has no carrier there (measured above). The ruling's
minorwas for "the newreason value"; that value lives on
@objectstack/platform-objects, which therefore takesthe
minor.@objectstack/plugin-auth:minor, notpatch. It gains three public exports —MEMBERSHIP_ENDED_REVOKE_REASON,endSessionClaimsForEndedMembership,registerMembershipEndedSessionTrigger. A new export is a minor by semver;patchwouldhave been right had the value lived in spec and the implementing package added no surface.
If either call should be otherwise, it is a one-line change to the changeset.
Ablation — the trigger can go red, proven on disk in both directions
Mutation: guard the registration behind a constant in
auth-plugin.ts.Rebuilt, and proven to have reached the artifact the dogfood suite actually consumes:
Ablated run — all three go red, the first on exactly the right assertion
(
revoked.revoked_at,expected null to be truthy: the session survived the removal):Restore leg, proven the same way rather than trusted:
Acceptance and its three controls
packages/qa/dogfood/test/membership-ended-session-revoke.dogfood.test.ts— real bootedstack, real better-auth, driven through the endpoint the card names. All three controls run
in the same test as the case they control, not in a different run.
deleted),
revoke_reason == organization_membership_ended,expires_atin the past, andGET /auth/get-sessionon the same token comes back unauthenticated.packages/core/src/security/resolve-authz-context.test.ts86 passed,packages/rest/src/single-kernel-isolated-session-org-claim-matrix.test.ts13 passed,and the per-request check's own file is byte-identical to base.
Plus
packages/plugins/plugin-auth/src/membership-ended-session.test.ts(15 tests) for thedecision table, the shapes that must be no-ops (a different organization, an already-revoked
row, an expired row, a role change), the bounded scan, and the two degradation paths.
Tests, at
61bc592f3Gates — 113 derived, 104 green, 9 NOT MEASURED with reasons
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstackderived its ownchange set (17 paths, three-dot against merge base
59953d5a3) at61bc592f3. Re-derivedafter the docs edits, which pulled in 21 further gates the first derivation did not name,
and again after the patch round below.
Four were genuinely red and are fixed here:
check:doc-authoringcheck:objectql-double-limitlimitcheck:where-matchercheck:system-context-census/check:tenant-audit-censusThe nine non-zero results, every one NOT MEASURED rather than red:
check:partof-closing-keyword,check:single-claim-paths— exit 2, NOT WIRED: bothneed
PR_BODY/PR_NUMBER. Their own words: "a wiring or usage failure, NOT a verdict".CI supplies the context.
check:test-completeness,check:dual-build-cjs-loads,check:published-readme-exports,check:skill-examples— exit 3 / PREREQUISITE NOT MET: they read built output thewhole-repo
pnpm buildproduces. Each says so itself and says it is not a pass.check:react-declaration-parity— needs an objectui manifest and a browser dump.pr-labels.mjs— a usage error; the runner invoked it with no mode flag.check:merge-driver— pre-existing and environmental, not this change set: twoself-test cases about "a gate whose RUNNER is not installed" fail on this host, and
git diff 59953d5a3 -- scripts/ .gitattributesis empty, so the verdict cannot be afunction of this diff.
Declared narrowing — verification ran UNLOCKED.
scripts/pm/os-verify-lock.shcould nottake the shared verify lock on this host: no usable
flock. The shared verify lock isdeclared Linux-only (
flockis util-linux, and a stock macOS does not ship it), so everycommand above was run directly, without the lock — a declared narrowing, not a silent one.
No serialization guarantee held for these runs.
Patch round —
check:i18nwas red, and the miss was in my gate extractionCI failed
Type Check · consumer gatesat the step runningpnpm check:i18n. Reproducedlocally before acting rather than fixed on a hypothesis. The first local run answered exit
3 — PREREQUISITE NOT MET ("the workspace CLI is not built … Nothing was checked"), which
is a refusal and not a verdict; with the closure the gate names built, the real verdict was:
Cause, confirmed by measurement: the three
*.source-hashes.generated.tscompanionswere left stale by the field-description change — the hashes are keyed on the source
strings.
pnpm i18n:extractregenerates both families in one pass and changed exactly thosethree files and nothing else; the gate then reported
OK (9 package(s) — all bundles in sync). Nothing was hand-edited to reach green.The three consequence checks
--fill=defaultdemands:check:i18nOK (9 package(s) — all bundles in sync, no undeclared authoring keys)check:i18n-coverageOK (13 config(s), 621 baselined untranslated string(s), none new)— no growth, so no rebaselinecheck:i18n-stale-fillOK (10 bundle set(s) — no new stale fills, 0 baselined)Why this escaped the first round, stated plainly: the tool named the gate and my
extraction dropped it.
dispatch-gatesprintedcheck:i18nunder Convention-triggeredgates ("edits a file in a package that owns an i18n-extract.config.ts"), a section indented
four spaces; the regex I scraped its output with anchored on a two-space bullet and silently
lost that whole section. Five gates were dropped that way —
check:i18n,check:dispatcher-error-vocabulary,check:query-options-erasure,check:type-check-coverage,check:type-check-debt. All five have now been run and allfive are green (
check:type-check-debtneeded the whole workspace built first:12 ledger entr(ies) re-measured, 140 raw tsc error(s) total, none above its recorded number). That is the corrected 113-gate total above.Re-verified at the patch head
61bc592f3, not at the previous one: plugin-auth 2050 tests,platform-objects 518 tests, the dogfood acceptance file 3 tests, both typechecks Done, and
the full 113-gate union. Pushed append-only —
24536a537is still an ancestor of the branchhead; no rebase, no force-push, no squash.
Known open, by measurement rather than omission
A raw driver delete bypasses this trigger entirely (census PATH 4), and cloud's
package-uninstall sample-data purge is one such caller — cloud#2003. The per-request
membership check covers it; the courtesy does not. That asymmetry is the whole reason this
card was filed separately from the boundary card, and it is why nothing here may be traded
against that check.
Generated by Claude Code